"""Implements the 'bdist_mac' commands (create macOS
app blundle).
"""

from __future__ import annotations

import os
import plistlib
import shutil
import subprocess
from pathlib import Path
from typing import ClassVar

from setuptools import Command

from cx_Freeze.common import normalize_to_list
from cx_Freeze.darwintools import (
    apply_adhoc_signature,
    change_load_reference,
    isMachOFile,
)
from cx_Freeze.exception import OptionError

__all__ = ["bdist_mac"]


class bdist_mac(Command):
    """Create a macOS application bundle."""

    description = "create a macOS application bundle"

    plist_items: list[tuple[str, str]]
    include_frameworks: list[str]
    include_resources: list[str]

    user_options: ClassVar[list[tuple[str, str | None, str]]] = [
        ("iconfile=", None, "Path to an icns icon file for the application."),
        (
            "qt-menu-nib=",
            None,
            "Location of qt_menu.nib folder for Qt "
            "applications. Will be auto-detected by default.",
        ),
        (
            "bundle-name=",
            None,
            "File name for the bundle application without the .app extension.",
        ),
        (
            "plist-items=",
            None,
            "A list of key-value pairs (type: list[tuple[str, str]]) to "
            "be added to the app bundle Info.plist file.",
        ),
        (
            "custom-info-plist=",
            None,
            "File to be used as the Info.plist in "
            "the app bundle. A basic one will be generated by default.",
        ),
        (
            "include-frameworks=",
            None,
            "A comma separated list of Framework "
            "directories to include in the app bundle.",
        ),
        (
            "include-resources=",
            None,
            "A list of tuples of additional "
            "files to include in the app bundle's resources directory, with "
            "the first element being the source, and second the destination "
            "file or directory name.",
        ),
        (
            "codesign-identity=",
            None,
            "The identity of the key to be used to sign the app bundle.",
        ),
        (
            "codesign-entitlements=",
            None,
            "The path to an entitlements file "
            "to use for your application's code signature.",
        ),
        (
            "codesign-deep=",
            None,
            "Boolean for whether to codesign using the --deep option.",
        ),
        (
            "codesign-timestamp",
            None,
            "Boolean for whether to codesign using the --timestamp option.",
        ),
        (
            "codesign-resource-rules",
            None,
            "Plist file to be passed to codesign's --resource-rules option.",
        ),
        (
            "absolute-reference-path=",
            None,
            "Path to use for all referenced "
            "libraries instead of @executable_path.",
        ),
        (
            "codesign-verify",
            None,
            "Boolean to verify codesign of the .app bundle using the codesign "
            "command",
        ),
        (
            "spctl-assess",
            None,
            "Boolean to verify codesign of the .app bundle using the spctl "
            "command",
        ),
        (
            "codesign-strict=",
            None,
            "Boolean for whether to codesign using the --strict option.",
        ),
        (
            "codesign-options=",
            None,
            "Option flags to be embedded in the code signature",
        ),
    ]

    def initialize_options(self) -> None:
        self.list_options = [
            "plist_items",
            "include_frameworks",
            "include_resources",
        ]
        for option in self.list_options:
            setattr(self, option, [])

        self.absolute_reference_path = None
        self.bundle_name = self.distribution.get_fullname()
        self.codesign_deep = None
        self.codesign_entitlements = None
        self.codesign_identity = None
        self.codesign_timestamp = None
        self.codesign_strict = None
        self.codesign_options = None
        self.codesign_resource_rules = None
        self.codesign_verify = None
        self.spctl_assess = None
        self.custom_info_plist = None
        self.iconfile = None
        self.qt_menu_nib = False

        self.build_base = None
        self.build_dir = None

    def finalize_options(self) -> None:
        # Make sure all options of multiple values are lists
        for option in self.list_options:
            setattr(self, option, normalize_to_list(getattr(self, option)))
        for item in self.plist_items:
            if not isinstance(item, tuple) or len(item) != 2:
                msg = (
                    "Error, plist_items must be a list of key, value pairs "
                    "(list[tuple[str, str]]) (bad list item)."
                )
                raise OptionError(msg)

        # Define the paths within the application bundle
        self.set_undefined_options(
            "build_exe",
            ("build_base", "build_base"),
            ("build_exe", "build_dir"),
        )
        self.bundle_dir = os.path.join(
            self.build_base, f"{self.bundle_name}.app"
        )
        self.contents_dir = os.path.join(self.bundle_dir, "Contents")
        self.bin_dir = os.path.join(self.contents_dir, "MacOS")
        self.frameworks_dir = os.path.join(self.contents_dir, "Frameworks")
        self.resources_dir = os.path.join(self.contents_dir, "Resources")
        self.helpers_dir = os.path.join(self.contents_dir, "Helpers")

    def create_plist(self) -> None:
        """Create the Contents/Info.plist file."""
        # Use custom plist if supplied, otherwise create a simple default.
        if self.custom_info_plist:
            with open(self.custom_info_plist, "rb") as file:
                contents = plistlib.load(file)
        else:
            contents = {
                "CFBundleIconFile": "icon.icns",
                "CFBundleDevelopmentRegion": "English",
                "CFBundleIdentifier": self.bundle_name,
                # Specify that bundle is an application bundle
                "CFBundlePackageType": "APPL",
                # Cause application to run in high-resolution mode by default
                # (Without this, applications run from application bundle may
                # be pixelated)
                "NSHighResolutionCapable": "True",
            }

        # Ensure CFBundleExecutable is set correctly
        contents["CFBundleExecutable"] = self.bundle_executable

        # add custom items to the plist file
        for key, value in self.plist_items:
            contents[key] = value

        with open(os.path.join(self.contents_dir, "Info.plist"), "wb") as file:
            plistlib.dump(contents, file)

    def set_absolute_reference_paths(self, path=None) -> None:
        """For all files in Contents/MacOS, set their linked library paths to
        be absolute paths using the given path instead of @executable_path.
        """
        if not path:
            path = self.absolute_reference_path

        files = os.listdir(self.bin_dir)

        for filename in files:
            filepath = os.path.join(self.bin_dir, filename)

            # Skip some file types
            if filepath[-1:] in ("txt", "zip") or os.path.isdir(filepath):
                continue

            out = subprocess.check_output(
                ("otool", "-L", filepath), encoding="utf_8"
            )
            for line in out.splitlines()[1:]:
                lib = line.lstrip("\t").split(" (compat")[0]

                if lib.startswith("@executable_path"):
                    replacement = lib.replace("@executable_path", path)

                    path, name = os.path.split(replacement)

                    # see if we provide the referenced file;
                    # if so, change the reference
                    if name in files:
                        change_load_reference(filepath, lib, replacement)
            apply_adhoc_signature(filepath)

    def find_qt_menu_nib(self) -> str | None:
        """Returns a location of a qt_menu.nib folder, or None if this is not
        a Qt application.
        """
        if self.qt_menu_nib:
            return self.qt_menu_nib
        if any(n.startswith("PyQt4.QtCore") for n in os.listdir(self.bin_dir)):
            name = "PyQt4"
        elif any(
            n.startswith("PySide.QtCore") for n in os.listdir(self.bin_dir)
        ):
            name = "PySide"
        else:
            return None

        qtcore = __import__(name, fromlist=["QtCore"]).QtCore
        libpath = str(
            qtcore.QLibraryInfo.location(qtcore.QLibraryInfo.LibrariesPath)
        )
        for subpath in [
            "QtGui.framework/Resources/qt_menu.nib",
            "Resources/qt_menu.nib",
        ]:
            path = os.path.join(libpath, subpath)
            if os.path.exists(path):
                return path

        # Last resort: fixed paths (macports)
        for path in [
            "/opt/local/Library/Frameworks/QtGui.framework/Versions/"
            "4/Resources/qt_menu.nib"
        ]:
            if os.path.exists(path):
                return path

        print("Could not find qt_menu.nib")
        msg = "Could not find qt_menu.nib"
        raise OSError(msg)

    def prepare_qt_app(self) -> None:
        """Add resource files for a Qt application. Should do nothing if the
        application does not use QtCore.
        """
        qt_conf = os.path.join(self.resources_dir, "qt.conf")
        qt_conf_2 = os.path.join(self.resources_dir, "qt_bdist_mac.conf")
        if os.path.exists(qt_conf_2):
            self.execute(
                shutil.move,
                (qt_conf_2, qt_conf),
                msg=f"moving {qt_conf_2} -> {qt_conf}",
            )

        nib_locn = self.find_qt_menu_nib()
        if nib_locn is None:
            return

        # Copy qt_menu.nib
        self.copy_tree(
            nib_locn, os.path.join(self.resources_dir, "qt_menu.nib")
        )

        # qt.conf needs to exist, but needn't have any content
        if not os.path.exists(qt_conf):
            with open(qt_conf, "wb"):
                pass

    def run(self) -> None:
        self.run_command("build_exe")

        # Remove App if it already exists
        # ( avoids confusing issues where prior builds persist! )
        if os.path.exists(self.bundle_dir):
            self.execute(
                shutil.rmtree,
                (self.bundle_dir,),
                msg=f"staging - removed existing '{self.bundle_dir}'",
            )

        # Find the executable name
        executable = self.distribution.executables[0].target_name
        _, self.bundle_executable = os.path.split(executable)
        print(f"Executable name: {self.build_dir}/{executable}")

        # Build the app directory structure
        self.mkpath(self.bin_dir)  # /MacOS
        self.mkpath(self.frameworks_dir)  # /Frameworks
        self.mkpath(self.resources_dir)  # /Resources

        # Copy the full build_exe to Contents/Resources
        self.copy_tree(self.build_dir, self.resources_dir)

        # Move only executables in Contents/Resources to Contents/MacOS
        for executable in self.distribution.executables:
            source = os.path.join(self.resources_dir, executable.target_name)
            target = os.path.join(self.bin_dir, executable.target_name)
            self.move_file(source, target)

        # Make symlink between folders under Resources such as lib and others
        # specified by the user in include_files and Contents/MacOS so we can
        # use non-relative reference paths to pass codesign...
        for filename in os.listdir(self.resources_dir):
            target = os.path.join(self.resources_dir, filename)
            if os.path.isdir(target):
                origin = os.path.join(self.bin_dir, filename)
                relative_reference = os.path.relpath(target, self.bin_dir)
                self.execute(
                    os.symlink,
                    (relative_reference, origin, True),
                    msg=f"linking {origin} -> {relative_reference}",
                )

        # Copy the icon
        if self.iconfile:
            self.copy_file(
                self.iconfile, os.path.join(self.resources_dir, "icon.icns")
            )

        # Copy in Frameworks
        for framework in self.include_frameworks:
            self.copy_tree(
                framework,
                os.path.join(self.frameworks_dir, os.path.basename(framework)),
                preserve_mode=True,
                preserve_symlinks=True,
            )

        # Copy in Resources
        for resource, destination in self.include_resources:
            if os.path.isdir(resource):
                self.copy_tree(
                    resource, os.path.join(self.resources_dir, destination)
                )
            else:
                parent_dirs = os.path.dirname(
                    os.path.join(self.resources_dir, destination)
                )
                os.makedirs(parent_dirs, exist_ok=True)
                self.copy_file(
                    resource, os.path.join(self.resources_dir, destination)
                )

        # Create the Info.plist file
        self.execute(self.create_plist, (), msg="creating Contents/Info.plist")

        # Make library references absolute if enabled
        if self.absolute_reference_path:
            self.execute(
                self.set_absolute_reference_paths,
                (),
                msg="set absolute reference path "
                f"'{self.absolute_reference_path}",
            )

        # For a Qt application, run some tweaks
        self.execute(self.prepare_qt_app, ())

        # Move Contents/Resources/share/*.app to Contents/Helpers
        share_dir = os.path.join(self.resources_dir, "share")
        if os.path.isdir(share_dir):
            for filename in os.listdir(share_dir):
                if not filename.endswith(".app"):
                    continue
                # create /Helpers only if required
                self.mkpath(self.helpers_dir)
                source = os.path.join(share_dir, filename)
                target = os.path.join(self.helpers_dir, filename)
                self.execute(
                    shutil.move,
                    (source, target),
                    msg=f"moving {source} -> {target}",
                )
                if os.path.isdir(target):
                    origin = os.path.join(target, "Contents", "MacOS", "lib")
                    relative_reference = os.path.relpath(
                        os.path.join(self.resources_dir, "lib"),
                        os.path.join(target, "Contents", "MacOS"),
                    )
                    self.execute(
                        os.symlink,
                        (relative_reference, origin, True),
                        msg=f"linking {origin} -> {relative_reference}",
                    )

        # Sign the app bundle if a key is specified
        self.execute(
            self._codesign,
            (self.bundle_dir,),
            msg=f"sign: '{self.bundle_dir}'",
        )

    def _codesign(self, root_path) -> None:
        """Run codesign on all .so, .dylib and binary files in reverse order.
        Signing from inside-out.
        """
        if not self.codesign_identity:
            return

        binaries_to_sign = []

        # Identify all binary files
        for dirpath, _, filenames in os.walk(root_path):
            for filename in filenames:
                full_path = Path(os.path.join(dirpath, filename))

                if isMachOFile(full_path):
                    binaries_to_sign.append(full_path)

        # Sort files by depth, so we sign the deepest files first
        binaries_to_sign.sort(key=lambda x: str(x).count(os.sep), reverse=True)

        for binary_path in binaries_to_sign:
            self._codesign_file(binary_path, self._get_sign_args())

        self._verify_signature()
        print("Finished .app signing")

    def _get_sign_args(self) -> list[str]:
        signargs = ["codesign", "--sign", self.codesign_identity, "--force"]

        if self.codesign_timestamp:
            signargs.append("--timestamp")

        if self.codesign_strict:
            signargs.append(f"--strict={self.codesign_strict}")

        if self.codesign_deep:
            signargs.append("--deep")

        if self.codesign_options:
            signargs.append("--options")
            signargs.append(self.codesign_options)

        if self.codesign_entitlements:
            signargs.append("--entitlements")
            signargs.append(self.codesign_entitlements)
        return signargs

    def _codesign_file(self, file_path, sign_args) -> None:
        print(f"Signing file: {file_path}")
        sign_args.append(file_path)
        subprocess.run(sign_args, check=False)

    def _verify_signature(self) -> None:
        if self.codesign_verify:
            verify_args = [
                "codesign",
                "-vvv",
                "--deep",
                "--strict",
                self.bundle_dir,
            ]
            print("Running codesign verification")
            result = subprocess.run(
                verify_args, capture_output=True, text=True, check=False
            )
            print("ExitCode:", result.returncode)
            print(" stdout:", result.stdout)
            print(" stderr:", result.stderr)

        if self.spctl_assess:
            spctl_args = [
                "spctl",
                "--assess",
                "--raw",
                "--verbose=10",
                "--type",
                "exec",
                self.bundle_dir,
            ]
            try:
                completed_process = subprocess.run(
                    spctl_args,
                    check=True,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                )
                print(
                    "spctl command's output: "
                    f"{completed_process.stdout.decode()}"
                )
            except subprocess.CalledProcessError as error:
                print(f"spctl check got an error: {error.stdout.decode()}")
                raise
